🎖️GitЯра🎖️
Commit a4be81c3a94c216b6f12c64dfa53e402d87d68ee
Parents : 5572b29
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-25T14:24:31-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-25T19:24:31Z
feat(node): group related metric cards into vertical columns (#6431)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Changes
18 files changed, 455 insertions(+), 240 deletions(-)
Diff
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt
index 0499611f9e..d9c50c2db7 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt
@@ -16,13 +16,8 @@
*/
package org.meshtastic.feature.node.component
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.StringResource
@@ -72,39 +67,46 @@ private fun buildAirQualityCards(
tempIcon: ImageVector,
humidityIcon: ImageVector,
isFahrenheit: Boolean,
-): List<VectorMetricInfo> = buildList {
+): List<MetricGroup> = buildList {
// A present reading of 0 is a valid value (e.g. clean air at 0 µg/m³), so only the `?.` null-check (an
// absent metric) hides a card — matching the #5793 chart/CSV zero-suppression fix.
- metrics.pm10_standard?.let { pm -> add(VectorMetricInfo(Res.string.pm1_0, "$pm $ugm3", icon)) }
+ metrics.pm10_standard?.let { pm -> add(VectorMetricInfo(Res.string.pm1_0, "$pm $ugm3", icon).asGroup()) }
metrics.pm25_standard?.let { pm ->
- add(VectorMetricInfo(Res.string.pm2_5, "$pm $ugm3", icon))
- // AQI sits alongside the raw PM2.5 reading, so only show it when that raw reading is present.
- aqi?.let { (aqiValue, severity) ->
- add(VectorMetricInfo(Res.string.aqi, "$aqiValue (${severity.label})", icon))
- }
+ add(
+ listOfNotNull(
+ VectorMetricInfo(Res.string.pm2_5, "$pm $ugm3", icon),
+ // AQI is derived from the raw PM2.5 reading, so it stacks directly under it — and is only shown when
+ // that raw reading is present.
+ aqi?.let { (aqiValue, severity) ->
+ VectorMetricInfo(Res.string.aqi, "$aqiValue (${severity.label})", icon)
+ },
+ ),
+ )
}
- metrics.pm100_standard?.let { pm -> add(VectorMetricInfo(Res.string.pm10, "$pm $ugm3", icon)) }
- metrics.co2?.let { co2 -> add(VectorMetricInfo(Res.string.co2, "$co2 $ppmUnit", icon)) }
+ metrics.pm100_standard?.let { pm -> add(VectorMetricInfo(Res.string.pm10, "$pm $ugm3", icon).asGroup()) }
// The SCD4x CO₂ sensor also reports its own temperature/humidity (#5873) — surfaced here so a node can double as a
- // weather station without a separate BME sensor. `?.` hides only genuinely-absent readings.
- metrics.co2_temperature?.let { temp ->
- add(VectorMetricInfo(Res.string.co2_temperature, temp.toTempString(isFahrenheit), tempIcon))
- }
- metrics.co2_humidity?.let { hum ->
- add(VectorMetricInfo(Res.string.co2_humidity, "${NumberFormatter.format(hum, 0)}%", humidityIcon))
- }
+ // weather station without a separate BME sensor. All three come from that one sensor, so they share a column.
+ // `?.` hides only genuinely-absent readings.
+ add(
+ listOfNotNull(
+ metrics.co2?.let { co2 -> VectorMetricInfo(Res.string.co2, "$co2 $ppmUnit", icon) },
+ metrics.co2_temperature?.let { temp ->
+ VectorMetricInfo(Res.string.co2_temperature, temp.toTempString(isFahrenheit), tempIcon)
+ },
+ metrics.co2_humidity?.let { hum ->
+ VectorMetricInfo(Res.string.co2_humidity, "${NumberFormatter.format(hum, 0)}%", humidityIcon)
+ },
+ ),
+ )
}
-private fun metricValueColor(
- label: StringResource,
- co2Color: Color?,
- aqiSeverity: PmAqiSeverity?,
- defaultColor: Color,
-): Color = when (label) {
- Res.string.co2 -> co2Color
- Res.string.aqi -> aqiSeverity?.color
- else -> null
-} ?: defaultColor
+/** Severity color for a metric's value text, or null to keep the default card color. */
+private fun metricValueColor(label: StringResource, co2Color: Color?, aqiSeverity: PmAqiSeverity?): Color? =
+ when (label) {
+ Res.string.co2 -> co2Color
+ Res.string.aqi -> aqiSeverity?.color
+ else -> null
+ }
/**
* Displays air quality info cards for a node showing PM1.0, PM2.5, PM10 and CO₂ values. A card is shown for each metric
@@ -134,24 +136,10 @@ internal fun AirQualityInfoCards(
buildAirQualityCards(metrics, aqi, ugm3, ppmUnit, icon, tempIcon, humidityIcon, isFahrenheit)
}
- if (cards.isEmpty()) return
+ if (cards.none { it.isNotEmpty() }) return
val co2Color = Co2Severity.fromPpm(metrics.co2 ?: 0)?.color
val aqiSeverity = aqi?.second
- val defaultColor = MaterialTheme.colorScheme.onSurface
- FlowRow(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalArrangement = Arrangement.SpaceEvenly,
- ) {
- cards.forEach { metric ->
- InfoCard(
- icon = metric.icon,
- text = stringResource(metric.label),
- value = metric.value,
- valueColor = metricValueColor(metric.label, co2Color, aqiSeverity, defaultColor),
- )
- }
- }
+ MetricCardFlow(groups = cards, valueColor = { metric -> metricValueColor(metric.label, co2Color, aqiSeverity) })
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
index 14c492b324..d03707ec59 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
@@ -16,12 +16,7 @@
*/
package org.meshtastic.feature.node.component
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.UnitConversions
@@ -66,6 +61,13 @@ import org.meshtastic.feature.node.model.DrawableMetricInfo
import org.meshtastic.feature.node.model.VectorMetricInfo
import org.meshtastic.proto.Config
+/**
+ * Displays environmental metrics for a node.
+ *
+ * Readings that come from one physical sensor, or that are derived from each other, are grouped into a shared vertical
+ * column (temperature with its dew point, voltage with current, soil temperature with soil moisture, visible with UV
+ * light) — see issue #4507. Every other reading is its own single-card column.
+ */
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
internal fun EnvironmentMetrics(
@@ -73,26 +75,39 @@ internal fun EnvironmentMetrics(
displayUnits: Config.DisplayConfig.DisplayUnits,
isFahrenheit: Boolean = false,
) {
- val vectorMetrics = buildList {
+ val groups: List<MetricGroup> = buildList {
with(node.environmentMetrics) {
- temperature?.let { temp ->
- if (!temp.isNaN()) {
- add(
+ val temperatureCard =
+ temperature
+ ?.takeUnless { it.isNaN() }
+ ?.let {
VectorMetricInfo(
label = Res.string.temperature,
- value = temp.toTempString(isFahrenheit),
+ value = it.toTempString(isFahrenheit),
icon = MeshtasticIcons.Temperature,
- ),
- )
- }
- }
+ )
+ }
+ // Dew point is computed from temperature and humidity, so it belongs under the temperature it came from.
+ val dewPointCard =
+ temperature
+ ?.let { temp -> relative_humidity?.let { rh -> UnitConversions.calculateDewPoint(temp, rh) } }
+ ?.takeUnless { it.isNaN() }
+ ?.let {
+ DrawableMetricInfo(
+ label = Res.string.dew_point,
+ value = it.toTempString(isFahrenheit),
+ icon = Res.drawable.ic_dew_point,
+ )
+ }
+ add(listOfNotNull(temperatureCard, dewPointCard))
relative_humidity?.let { rh ->
add(
VectorMetricInfo(
label = Res.string.humidity,
value = "${NumberFormatter.format(rh, 0)}%",
icon = MeshtasticIcons.Humidity,
- ),
+ )
+ .asGroup(),
)
}
barometric_pressure?.let { bp ->
@@ -101,7 +116,8 @@ internal fun EnvironmentMetrics(
label = Res.string.pressure,
value = "${NumberFormatter.format(bp, 0)} hPa",
icon = MeshtasticIcons.Pressure,
- ),
+ )
+ .asGroup(),
)
}
gas_resistance?.let { gr ->
@@ -110,57 +126,64 @@ internal fun EnvironmentMetrics(
label = Res.string.gas_resistance,
value = "${NumberFormatter.format(gr, 0)} MΩ",
icon = MeshtasticIcons.Particulate,
- ),
- )
- }
- voltage?.let { v ->
- add(
- VectorMetricInfo(
- label = Res.string.voltage,
- value = "${NumberFormatter.format(v, 2)}V",
- icon = MeshtasticIcons.Voltage,
- ),
+ )
+ .asGroup(),
)
}
- current?.let { c ->
+ // Voltage and current describe the same supply, so they stack like a power channel does.
+ add(
+ listOfNotNull(
+ voltage?.let {
+ VectorMetricInfo(
+ label = Res.string.voltage,
+ value = "${NumberFormatter.format(it, 2)}V",
+ icon = MeshtasticIcons.Voltage,
+ )
+ },
+ current?.let {
+ VectorMetricInfo(
+ label = Res.string.current,
+ value = "${NumberFormatter.format(it, 1)}mA",
+ icon = MeshtasticIcons.PowerSupply,
+ )
+ },
+ ),
+ )
+ iaq?.let { i ->
add(
- VectorMetricInfo(
- label = Res.string.current,
- value = "${NumberFormatter.format(c, 1)}mA",
- icon = MeshtasticIcons.PowerSupply,
- ),
+ VectorMetricInfo(label = Res.string.iaq, value = i.toString(), icon = MeshtasticIcons.AirQuality)
+ .asGroup(),
)
}
- iaq?.let { i ->
- add(VectorMetricInfo(label = Res.string.iaq, value = i.toString(), icon = MeshtasticIcons.AirQuality))
- }
distance?.let { d ->
add(
VectorMetricInfo(
label = Res.string.distance,
value = d.toSmallDistanceString(displayUnits),
icon = MeshtasticIcons.Altitude,
- ),
- )
- }
- lux?.let { l ->
- add(
- VectorMetricInfo(
- label = Res.string.lux,
- value = "${NumberFormatter.format(l, 0)} lx",
- icon = MeshtasticIcons.LightMode,
- ),
- )
- }
- uv_lux?.let { uvl ->
- add(
- VectorMetricInfo(
- label = Res.string.uv_lux,
- value = "${NumberFormatter.format(uvl, 0)} lx",
- icon = MeshtasticIcons.LightMode,
- ),
+ )
+ .asGroup(),
)
}
+ // Visible and UV illuminance are the same light sensor reported two ways.
+ add(
+ listOfNotNull(
+ lux?.let {
+ VectorMetricInfo(
+ label = Res.string.lux,
+ value = "${NumberFormatter.format(it, 0)} lx",
+ icon = MeshtasticIcons.LightMode,
+ )
+ },
+ uv_lux?.let {
+ VectorMetricInfo(
+ label = Res.string.uv_lux,
+ value = "${NumberFormatter.format(it, 0)} lx",
+ icon = MeshtasticIcons.LightMode,
+ )
+ },
+ ),
+ )
wind_speed?.let { ws ->
@Suppress("MagicNumber")
val normalizedBearing = ((wind_direction ?: 0) + 180) % 360
@@ -170,7 +193,8 @@ internal fun EnvironmentMetrics(
value = ws.toSpeedString(displayUnits),
icon = MeshtasticIcons.WindDirection,
rotateIcon = normalizedBearing.toFloat(),
- ),
+ )
+ .asGroup(),
)
}
weight?.let { w ->
@@ -179,51 +203,42 @@ internal fun EnvironmentMetrics(
label = Res.string.weight,
value = "${NumberFormatter.format(w, 2)} kg",
icon = MeshtasticIcons.Weight,
- ),
- )
- }
- if (temperature != null && relative_humidity != null) {
- val dewPoint = UnitConversions.calculateDewPoint(temperature!!, relative_humidity!!)
- if (!dewPoint.isNaN()) {
- add(
- DrawableMetricInfo(
- label = Res.string.dew_point,
- value = dewPoint.toTempString(isFahrenheit),
- icon = Res.drawable.ic_dew_point,
- ),
- )
- }
- }
- soil_temperature?.let { st ->
- if (!st.isNaN()) {
- add(
- DrawableMetricInfo(
- label = Res.string.soil_temperature,
- value = st.toTempString(isFahrenheit),
- icon = Res.drawable.ic_soil_temperature,
- ),
)
- }
- }
- soil_moisture?.let { sm ->
- add(
- DrawableMetricInfo(
- label = Res.string.soil_moisture,
- value = "$sm%",
- icon = Res.drawable.ic_soil_moisture,
- ),
+ .asGroup(),
)
}
+ // Both soil readings come from the same probe.
+ add(
+ listOfNotNull(
+ soil_temperature
+ ?.takeUnless { it.isNaN() }
+ ?.let {
+ DrawableMetricInfo(
+ label = Res.string.soil_temperature,
+ value = it.toTempString(isFahrenheit),
+ icon = Res.drawable.ic_soil_temperature,
+ )
+ },
+ soil_moisture?.let {
+ DrawableMetricInfo(
+ label = Res.string.soil_moisture,
+ value = "$it%",
+ icon = Res.drawable.ic_soil_moisture,
+ )
+ },
+ ),
+ )
radiation?.let { r ->
add(
DrawableMetricInfo(
label = Res.string.radiation,
value = "${NumberFormatter.format(r, 1)} µR/h",
icon = Res.drawable.ic_radioactive,
- ),
+ )
+ .asGroup(),
)
}
- // 1-Wire temperature sensors (up to 8 channels)
+ // 1-Wire temperature sensors (up to 8 channels) — independent probes, so one card each.
one_wire_temperature
.filterNot { it.isNaN() }
.forEachIndexed { idx, temp ->
@@ -232,32 +247,11 @@ internal fun EnvironmentMetrics(
label = Res.string.one_wire_temperature,
value = "${idx + 1}: ${temp.toTempString(isFahrenheit)}",
icon = Res.drawable.ic_soil_temperature,
- ),
+ )
+ .asGroup(),
)
}
}
}
- FlowRow(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalArrangement = Arrangement.SpaceEvenly,
- ) {
- vectorMetrics.forEach { metric ->
- if (metric is DrawableMetricInfo) {
- DrawableInfoCard(
- iconRes = metric.icon,
- text = stringResource(metric.label),
- value = metric.value,
- rotateIcon = metric.rotateIcon,
- )
- } else if (metric is VectorMetricInfo) {
- InfoCard(
- icon = metric.icon,
- text = stringResource(metric.label),
- value = metric.value,
- rotateIcon = metric.rotateIcon,
- )
- }
- }
- }
+ MetricCardFlow(groups = groups)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt
index 9f7587127c..af3ef04ede 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt
@@ -35,6 +35,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.LocalClipboard
@@ -61,7 +62,7 @@ fun InfoCard(
icon: ImageVector? = null,
iconRes: DrawableResource? = null,
rotateIcon: Float = 0f,
- valueColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface,
+ valueColor: Color = MaterialTheme.colorScheme.onSurface,
) {
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
@@ -115,6 +116,20 @@ fun InfoCard(
}
@Composable
-internal fun DrawableInfoCard(iconRes: DrawableResource, text: String, value: String, rotateIcon: Float = 0f) {
- InfoCard(iconRes = iconRes, text = text, value = value, rotateIcon = rotateIcon)
+internal fun DrawableInfoCard(
+ iconRes: DrawableResource,
+ text: String,
+ value: String,
+ modifier: Modifier = Modifier,
+ rotateIcon: Float = 0f,
+ valueColor: Color = MaterialTheme.colorScheme.onSurface,
+) {
+ InfoCard(
+ iconRes = iconRes,
+ text = text,
+ value = value,
+ rotateIcon = rotateIcon,
+ modifier = modifier,
+ valueColor = valueColor,
+ )
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt
new file mode 100644
index 0000000000..dbfff72057
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.feature.node.model.DrawableMetricInfo
+import org.meshtastic.feature.node.model.MetricInfo
+import org.meshtastic.feature.node.model.VectorMetricInfo
+
+/** Cards that belong together — one physical sensor, or a reading and the value derived from it. */
+internal typealias MetricGroup = List<MetricInfo>
+
+/** Groups a single reading that has nothing to pair with, so it renders as a one-card column. */
+internal fun MetricInfo.asGroup(): MetricGroup = listOf(this)
+
+/** Height, in cards, that a column is packed to when a run of unrelated single metrics is stacked. */
+private const val PACKED_COLUMN_HEIGHT = 2
+
+/**
+ * Packs runs of consecutive single-metric groups into taller columns, preserving order.
+ *
+ * A [FlowRow] row is as tall as its tallest child, so mixing one-card columns with grouped two-card columns strands
+ * empty space beneath every single card. Stacking consecutive singles keeps rows an even height and reclaims most of
+ * that space; a single sandwiched between two multi-card groups still gets a column of its own.
+ */
+private fun List<MetricGroup>.packed(): List<MetricGroup> = buildList {
+ val singles = mutableListOf<MetricInfo>()
+ fun flushSingles() {
+ singles.chunked(PACKED_COLUMN_HEIGHT).forEach { add(it) }
+ singles.clear()
+ }
+ this@packed.filter { it.isNotEmpty() }
+ .forEach { group ->
+ if (group.size == 1) {
+ singles += group
+ } else {
+ flushSingles()
+ add(group)
+ }
+ }
+ flushSingles()
+}
+
+/**
+ * Lays metric cards out as a wrapping row of vertical columns, one column per [MetricGroup].
+ *
+ * Related readings (a channel's voltage and current, a temperature and its dew point) stay stacked together and share a
+ * column width; unrelated single readings are packed into columns of [PACKED_COLUMN_HEIGHT] so the grid keeps an even
+ * height. Both together pack the cards far more tightly than a flat row of independent cards — see issue #4507.
+ *
+ * [valueColor] overrides a card's value text color, for metrics that are color-coded by severity; returning null keeps
+ * the [InfoCard] default.
+ */
+@Composable
+internal fun MetricCardFlow(
+ groups: List<MetricGroup>,
+ modifier: Modifier = Modifier,
+ valueColor: (MetricInfo) -> Color? = { null },
+) {
+ FlowRow(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ groups.packed().forEach { group ->
+ // IntrinsicSize.Max + fillMaxWidth keeps every card in a column the same width as the column's widest.
+ Column(modifier = Modifier.width(IntrinsicSize.Max), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ group.forEach { metric -> MetricCard(metric = metric, valueColor = valueColor(metric)) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun MetricCard(metric: MetricInfo, valueColor: Color?) {
+ val cardModifier = Modifier.fillMaxWidth()
+ val label = stringResource(metric.label)
+ val resolvedValueColor = valueColor ?: MaterialTheme.colorScheme.onSurface
+ when (metric) {
+ is VectorMetricInfo ->
+ InfoCard(
+ icon = metric.icon,
+ text = label,
+ value = metric.value,
+ rotateIcon = metric.rotateIcon,
+ modifier = cardModifier,
+ valueColor = resolvedValueColor,
+ )
+
+ is DrawableMetricInfo ->
+ DrawableInfoCard(
+ iconRes = metric.icon,
+ text = label,
+ value = metric.value,
+ rotateIcon = metric.rotateIcon,
+ modifier = cardModifier,
+ valueColor = resolvedValueColor,
+ )
+ }
+}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
index 7a476f8325..c85a9d1edc 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
@@ -159,6 +159,101 @@ fun TelemetricActionsSectionLocalPreview() {
}
}
+// ---------------------------------------------------------------------------
+// PowerMetrics previews
+// ---------------------------------------------------------------------------
+
+@PreviewLightDark
+@Suppress("PreviewPublic")
+@Composable
+fun PowerMetricsPreview() {
+ val node =
+ previewData.mickeyMouse.copy(
+ powerMetrics =
+ org.meshtastic.proto.PowerMetrics(
+ ch1_voltage = 4.19f,
+ ch1_current = 128.4f,
+ ch2_voltage = 3.72f,
+ ch2_current = 12.5f,
+ ch3_voltage = 5.02f,
+ ch3_current = 431.7f,
+ ),
+ )
+ AppTheme { Surface { PowerMetrics(node = node) } }
+}
+
+@PreviewLightDark
+@Suppress("PreviewPublic")
+@Composable
+fun PowerMetricsPartialPreview() {
+ // Only channel 1 reports a voltage — a single column, matching the partial layout in issue #4507.
+ val node =
+ previewData.mickeyMouse.copy(
+ powerMetrics = org.meshtastic.proto.PowerMetrics(ch1_voltage = 4.19f, ch1_current = 128.4f),
+ )
+ AppTheme { Surface { PowerMetrics(node = node) } }
+}
+
+@PreviewLightDark
+@Suppress("PreviewPublic")
+@Composable
+fun PowerMetricsNoCurrentPreview() {
+ // Channels report voltage but no current at all — voltage-only columns, no fabricated 0.0mA cards.
+ val node =
+ previewData.mickeyMouse.copy(
+ powerMetrics = org.meshtastic.proto.PowerMetrics(ch1_voltage = 4.19f, ch2_voltage = 3.72f),
+ )
+ AppTheme { Surface { PowerMetrics(node = node) } }
+}
+
+// ---------------------------------------------------------------------------
+// EnvironmentMetrics / AirQualityInfoCards previews
+// ---------------------------------------------------------------------------
+
+@PreviewLightDark
+@Suppress("PreviewPublic")
+@Composable
+fun EnvironmentMetricsPreview() {
+ val node =
+ previewData.mickeyMouse.copy(
+ environmentMetrics =
+ org.meshtastic.proto.EnvironmentMetrics(
+ temperature = 21.5f,
+ relative_humidity = 47f,
+ barometric_pressure = 1013f,
+ gas_resistance = 1200f,
+ voltage = 4.19f,
+ current = 128.4f,
+ iaq = 62,
+ lux = 480f,
+ uv_lux = 12f,
+ soil_temperature = 18.2f,
+ soil_moisture = 33,
+ radiation = 0.15f,
+ ),
+ )
+ AppTheme { Surface { EnvironmentMetrics(node = node, displayUnits = Config.DisplayConfig.DisplayUnits.METRIC) } }
+}
+
+@PreviewLightDark
+@Suppress("PreviewPublic")
+@Composable
+fun AirQualityInfoCardsPreview() {
+ val node =
+ previewData.mickeyMouse.copy(
+ airQualityMetrics =
+ org.meshtastic.proto.AirQualityMetrics(
+ pm10_standard = 8,
+ pm25_standard = 12,
+ pm100_standard = 18,
+ co2 = 640,
+ co2_temperature = 22.1f,
+ co2_humidity = 44f,
+ ),
+ )
+ AppTheme { Surface { AirQualityInfoCards(node = node) } }
+}
+
// ---------------------------------------------------------------------------
// PositionInlineContent preview
// ---------------------------------------------------------------------------
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt
index b379052c2e..02e86edf9f 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/PowerMetrics.kt
@@ -16,12 +16,7 @@
*/
package org.meshtastic.feature.node.component
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.FlowRow
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
@@ -34,74 +29,30 @@ import org.meshtastic.core.ui.icon.Voltage
import org.meshtastic.feature.node.model.VectorMetricInfo
/**
- * Displays environmental metrics for a node, including temperature, humidity, pressure, and other sensor data.
- *
- * WARNING: All metrics must be added in pairs (e.g., voltage and current for each channel) due to the display logic,
- * which arranges metrics in columns of two. If an odd number of metrics is provided, the UI may not display as
- * intended.
+ * Displays power metrics for a node: for every channel reporting a non-zero voltage, its voltage and — when the channel
+ * reports one — its current are stacked in a single vertical column so the pair stays visually grouped and the columns
+ * flow side by side.
*/
@Composable
-@Suppress("LongMethod", "CyclomaticComplexMethod")
internal fun PowerMetrics(node: Node) {
- val metrics = buildList {
+ val channels =
with(node.powerMetrics) {
- if ((ch1_voltage ?: 0f) != 0f) {
- add(
- VectorMetricInfo(
- Res.string.channel_1,
- "${NumberFormatter.format(ch1_voltage ?: 0f, 2)}V",
- MeshtasticIcons.Voltage,
- ),
- )
- add(
- VectorMetricInfo(
- Res.string.channel_1,
- "${NumberFormatter.format(ch1_current ?: 0f, 1)}mA",
- MeshtasticIcons.PowerSupply,
- ),
- )
- }
- if ((ch2_voltage ?: 0f) != 0f) {
- add(
- VectorMetricInfo(
- Res.string.channel_2,
- "${NumberFormatter.format(ch2_voltage ?: 0f, 2)}V",
- MeshtasticIcons.Voltage,
- ),
- )
- add(
- VectorMetricInfo(
- Res.string.channel_2,
- "${NumberFormatter.format(ch2_current ?: 0f, 1)}mA",
- MeshtasticIcons.PowerSupply,
- ),
- )
- }
- if ((ch3_voltage ?: 0f) != 0f) {
- add(
- VectorMetricInfo(
- Res.string.channel_3,
- "${NumberFormatter.format(ch3_voltage ?: 0f, 2)}V",
- MeshtasticIcons.Voltage,
- ),
- )
- add(
- VectorMetricInfo(
- Res.string.channel_3,
- "${NumberFormatter.format(ch3_current ?: 0f, 1)}mA",
- MeshtasticIcons.PowerSupply,
- ),
+ listOf(
+ Triple(Res.string.channel_1, ch1_voltage, ch1_current),
+ Triple(Res.string.channel_2, ch2_voltage, ch2_current),
+ Triple(Res.string.channel_3, ch3_voltage, ch3_current),
+ )
+ }
+ .filter { (_, voltage, _) -> (voltage ?: 0f) != 0f }
+ .map { (label, voltage, current) ->
+ // A reported current of 0mA is a real reading and is shown; only an absent one is hidden.
+ listOfNotNull(
+ VectorMetricInfo(label, "${NumberFormatter.format(voltage ?: 0f, 2)}V", MeshtasticIcons.Voltage),
+ current?.let {
+ VectorMetricInfo(label, "${NumberFormatter.format(it, 1)}mA", MeshtasticIcons.PowerSupply)
+ },
)
}
- }
- }
- FlowRow(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalArrangement = Arrangement.SpaceEvenly,
- ) {
- metrics.forEach { metric ->
- InfoCard(icon = metric.icon, text = stringResource(metric.label), value = metric.value)
- }
- }
+
+ MetricCardFlow(groups = channels)
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt
index a063e279eb..41aae00aa5 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt
@@ -20,16 +20,23 @@ import androidx.compose.ui.graphics.vector.ImageVector
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.StringResource
+/** A single labelled reading rendered as one info card; the icon comes from either a vector or a drawable resource. */
+internal sealed interface MetricInfo {
+ val label: StringResource
+ val value: String
+ val rotateIcon: Float
+}
+
internal data class VectorMetricInfo(
- val label: StringResource,
- val value: String,
+ override val label: StringResource,
+ override val value: String,
val icon: ImageVector,
- val rotateIcon: Float = 0f,
-)
+ override val rotateIcon: Float = 0f,
+) : MetricInfo
internal data class DrawableMetricInfo(
- val label: StringResource,
- val value: String,
+ override val label: StringResource,
+ override val value: String,
val icon: DrawableResource,
- val rotateIcon: Float = 0f,
-)
+ override val rotateIcon: Float = 0f,
+) : MetricInfo
diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt
index ce5977a87a..0bdad67e6f 100644
--- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt
+++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt
@@ -19,8 +19,10 @@ package org.meshtastic.screenshots.feature
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewLightDark
import com.android.tools.screenshot.PreviewTest
+import org.meshtastic.feature.node.component.AirQualityInfoCardsPreview
import org.meshtastic.feature.node.component.DeviceActionsLocalPreview
import org.meshtastic.feature.node.component.DeviceActionsRemotePreview
+import org.meshtastic.feature.node.component.EnvironmentMetricsPreview
import org.meshtastic.feature.node.component.HopHistogramContentPreview
import org.meshtastic.feature.node.component.HopHistogramEmptyPreview
import org.meshtastic.feature.node.component.NodeDetailsSectionPreview
@@ -34,6 +36,9 @@ import org.meshtastic.feature.node.component.NodeItemCompleteOnlineRemotePreview
import org.meshtastic.feature.node.component.NodeItemCompletePreview
import org.meshtastic.feature.node.component.NodeItemSignedPreview
import org.meshtastic.feature.node.component.PositionInlineContentPreview
+import org.meshtastic.feature.node.component.PowerMetricsNoCurrentPreview
+import org.meshtastic.feature.node.component.PowerMetricsPartialPreview
+import org.meshtastic.feature.node.component.PowerMetricsPreview
import org.meshtastic.feature.node.component.TelemetricActionsSectionEmptyPreview
import org.meshtastic.feature.node.component.TelemetricActionsSectionLocalPreview
import org.meshtastic.feature.node.component.TelemetricActionsSectionPreview
@@ -88,6 +93,41 @@ fun ScreenshotPositionInlineContent() {
PositionInlineContentPreview()
}
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotEnvironmentMetricsCards() {
+ EnvironmentMetricsPreview()
+}
+
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotAirQualityInfoCards() {
+ AirQualityInfoCardsPreview()
+}
+
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotPowerMetrics() {
+ PowerMetricsPreview()
+}
+
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotPowerMetricsPartial() {
+ PowerMetricsPartialPreview()
+}
+
+@PreviewTest
+@PreviewLightDark
+@Composable
+fun ScreenshotPowerMetricsNoCurrent() {
+ PowerMetricsNoCurrentPreview()
+}
+
@PreviewTest
@PreviewLightDark
@Composable
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityInfoCards_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityInfoCards_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..f31d89b43d
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityInfoCards_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityInfoCards_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityInfoCards_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..7825c14ac4
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotAirQualityInfoCards_Light_b29dc7a7_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotEnvironmentMetricsCards_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotEnvironmentMetricsCards_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..b4f43ea6ca
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotEnvironmentMetricsCards_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotEnvironmentMetricsCards_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotEnvironmentMetricsCards_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..099987d683
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotEnvironmentMetricsCards_Light_b29dc7a7_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsNoCurrent_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsNoCurrent_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..8fb30fc440
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsNoCurrent_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsNoCurrent_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsNoCurrent_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..794b4f43e8
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsNoCurrent_Light_b29dc7a7_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsPartial_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsPartial_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..11b1c603b7
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsPartial_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsPartial_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsPartial_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..fba1936e9c
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetricsPartial_Light_b29dc7a7_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetrics_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetrics_Dark_d19fbf1f_0.png
new file mode 100644
index 0000000000..fa8f436ee0
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetrics_Dark_d19fbf1f_0.png differ
diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetrics_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetrics_Light_b29dc7a7_0.png
new file mode 100644
index 0000000000..cef83a504e
Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotPowerMetrics_Light_b29dc7a7_0.png differ
Served by rngit 1.5.2 - Generated in 0.13s